* Add initial Variations Report to analytics feature.

* Restrict query to variations when not specifying any product IDs.

* Add route to get variations without specifying a parent.

* Move variations table component to variations report directory.

* Add missing LIMIT clause to variations report query.

* Remove broken features from Variations table.

* Add Variations report controller to CSV emailer.

* Add initial Variation Stats endpoint, based on Product Stats.

* Hook Variations Report components up to new stats endpoint.

* Hook attribute filter up to variations report queries.

* Remove variation title filter usage.

See: https://github.com/woocommerce/woocommerce-admin/pull/5100

* Use filtered separator in variation name formatting.

* Add "single variation" to variations report, fix autocompleter API request.

* Fix segmentation by variation.

* Add comparison to variations report.

* Always include manually specified variations in report results.

* Fix variations report table comparison mode.

The ReportTable component expects the `filter` query param.

* Fixing styling of compare button without table search component.

* Add variation filter to Orders report.

* Link orders count to orders report filtered by variation.

* Orders report: include variation attributes in product names.

* Further style tweaks for variations report download button.

* Add variations filter to order stats query.

* Clean up "category includes" login in REST controllers.

Prep for "category excludes" in the Variations report.

* Support category exclusion in report filters.

* Fix filter param used by the variation report table component.

* Add category filter to variations report.

* Fix initial selected ReportTable rows when using non-default compareParam.

* Add a new autocompleter for variable products.

* Add products filter to variations report.

* Fix tests.

* Handle variation IDs that are no longer found.

* Add documentation.

* Use getSetting() instead of directly accessing window properties in client code.

* Fix ordering Variations by SKU.
This commit is contained in:
Jeff Stieler 2020-09-25 09:57:48 -04:00 committed by GitHub
parent 1f667456b2
commit 264aa8dee4
47 changed files with 2193 additions and 293 deletions

View File

@ -59,6 +59,7 @@ const ReportTable = ( props ) => {
// eslint-disable-next-line no-unused-vars
tableQuery,
compareBy,
compareParam,
searchBy,
labels = {},
...tableProps
@ -69,7 +70,7 @@ const ReportTable = ( props ) => {
const { items, query: reportQuery } = tableData;
const initialSelectedRows = query.filter
const initialSelectedRows = query[ compareParam ]
? getIdsFromQuery( query[ compareBy ] )
: [];
const [ selectedRows, setSelectedRows ] = useState( initialSelectedRows );
@ -211,7 +212,6 @@ const ReportTable = ( props ) => {
};
const onCompare = () => {
const { compareParam } = props;
if ( compareBy ) {
onQueryChange( 'compare' )(
compareBy,
@ -222,7 +222,7 @@ const ReportTable = ( props ) => {
};
const onSearchChange = ( values ) => {
const { baseSearchQuery, compareParam } = props;
const { baseSearchQuery } = props;
// A comma is used as a separator between search terms, so we want to escape
// any comma they contain.
const searchTerms = values.map( ( v ) =>
@ -583,12 +583,8 @@ export default compose(
SETTINGS_STORE_NAME
).getSetting( 'wc_admin', 'wcAdminSettings' );
// Variations and Category charts are powered by the /reports/products/stats endpoint.
const chartEndpoint = [ 'variations', 'categories' ].includes(
endpoint
)
? 'products'
: endpoint;
// Category charts are powered by the /reports/products/stats endpoint.
const chartEndpoint = endpoint === 'categories' ? 'products' : endpoint;
const primaryData = getSummary
? getReportChartData( {
endpoint: chartEndpoint,

View File

@ -1,5 +1,3 @@
.woocommerce-report-table__scroll-point {
position: relative;
top: -#{$adminbar-height + $gap};
@ -68,6 +66,28 @@
}
}
&.has-compare:not(.has-search) {
.woocommerce-table__download-button {
align-self: center;
grid-column-start: 3;
}
@include breakpoint( '<960px' ) {
.woocommerce-table__download-button {
grid-area: 1 / 2 / 2 / 3;
}
.woocommerce-card__action {
grid-template-columns: auto;
.woocommerce-table__compare {
grid-area: 1 / 2 / 1 / 2;
justify-self: left;
position: absolute;
}
}
}
}
&.has-search:not(.has-compare) {
.woocommerce-card__action {
grid-template-columns: 1fr auto;

View File

@ -22,6 +22,11 @@ const RevenueReport = lazy( () =>
const ProductsReport = lazy( () =>
import( /* webpackChunkName: "analytics-report-products" */ './products' )
);
const VariationsReport = lazy( () =>
import(
/* webpackChunkName: "analytics-report-variations" */ './variations'
)
);
const OrdersReport = lazy( () =>
import( /* webpackChunkName: "analytics-report-orders" */ './orders' )
);
@ -58,6 +63,11 @@ export default () => {
title: __( 'Products', 'woocommerce-admin' ),
component: ProductsReport,
},
{
report: 'variations',
title: __( 'Variations', 'woocommerce-admin' ),
component: VariationsReport,
},
{
report: 'orders',
title: __( 'Orders', 'woocommerce-admin' ),

View File

@ -12,6 +12,7 @@ import {
getCouponLabels,
getProductLabels,
getTaxRateLabels,
getVariationLabels,
} from '../../../lib/async-requests';
const ORDERS_REPORT_CHARTS_FILTER = 'woocommerce_admin_orders_report_charts';
@ -156,6 +157,51 @@ export const advancedFilters = applyFilters(
getLabels: getProductLabels,
},
},
variation: {
labels: {
add: __( 'Variations', 'woocommerce-admin' ),
placeholder: __( 'Search variations', 'woocommerce-admin' ),
remove: __(
'Remove variations filter',
'woocommerce-admin'
),
rule: __(
'Select a variation filter match',
'woocommerce-admin'
),
/* translators: A sentence describing a Variation filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
title: __(
'{{title}}Variation{{/title}} {{rule /}} {{filter /}}',
'woocommerce-admin'
),
filter: __( 'Select variation', 'woocommerce-admin' ),
},
rules: [
{
value: 'includes',
/* translators: Sentence fragment, logical, "Includes" refers to orders including a given variation(s). Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
label: _x(
'Includes',
'variations',
'woocommerce-admin'
),
},
{
value: 'excludes',
/* translators: Sentence fragment, logical, "Excludes" refers to orders excluding a given variation(s). Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
label: _x(
'Excludes',
'variations',
'woocommerce-admin'
),
},
],
input: {
component: 'Search',
type: 'variations',
getLabels: getVariationLabels,
},
},
coupon: {
labels: {
add: __( 'Coupon Codes', 'woocommerce-admin' ),

View File

@ -17,7 +17,7 @@ import ProductsReportTable from './table';
import ReportChart from '../../components/report-chart';
import ReportError from '../../components/report-error';
import ReportSummary from '../../components/report-summary';
import VariationsReportTable from './table-variations';
import VariationsReportTable from '../variations/table';
import ReportFilters from '../../components/report-filters';
class ProductsReport extends Component {

View File

@ -0,0 +1,257 @@
/**
* External dependencies
*/
import { __, _x } from '@wordpress/i18n';
import { applyFilters } from '@wordpress/hooks';
/**
* Internal dependencies
*/
import {
getCategoryLabels,
getProductLabels,
getVariationLabels,
} from '../../../lib/async-requests';
const VARIATIONS_REPORT_CHARTS_FILTER =
'woocommerce_admin_variations_report_charts';
const VARIATIONS_REPORT_FILTERS_FILTER =
'woocommerce_admin_variations_report_filters';
const VARIATIONS_REPORT_ADVANCED_FILTERS_FILTER =
'woocommerce_admin_variations_report_advanced_filters';
export const charts = applyFilters( VARIATIONS_REPORT_CHARTS_FILTER, [
{
key: 'items_sold',
label: __( 'Items Sold', 'woocommerce-admin' ),
order: 'desc',
orderby: 'items_sold',
type: 'number',
},
{
key: 'net_revenue',
label: __( 'Net Sales', 'woocommerce-admin' ),
order: 'desc',
orderby: 'net_revenue',
type: 'currency',
},
{
key: 'orders_count',
label: __( 'Orders', 'woocommerce-admin' ),
order: 'desc',
orderby: 'orders_count',
type: 'number',
},
] );
export const filters = applyFilters( VARIATIONS_REPORT_FILTERS_FILTER, [
{
label: __( 'Show', 'woocommerce-admin' ),
staticParams: [ 'chartType', 'paged', 'per_page' ],
param: 'filter-variations',
showFilters: () => true,
filters: [
{
label: __( 'All Variations', 'woocommerce-admin' ),
chartMode: 'item-comparison',
value: 'all',
},
{
label: __( 'Single Variation', 'woocommerce-admin' ),
value: 'select_variation',
subFilters: [
{
component: 'Search',
value: 'single_variation',
path: [ 'select_variation' ],
settings: {
type: 'variations',
param: 'variations',
getLabels: getVariationLabels,
labels: {
placeholder: __(
'Type to search for a variation',
'woocommerce-admin'
),
button: __(
'Single Variation',
'woocommerce-admin'
),
},
},
},
],
},
{
label: __( 'Comparison', 'woocommerce-admin' ),
chartMode: 'item-comparison',
value: 'compare-variations',
settings: {
type: 'variations',
param: 'variations',
getLabels: getVariationLabels,
labels: {
helpText: __(
'Check at least two variations below to compare',
'woocommerce-admin'
),
placeholder: __(
'Search for variations to compare',
'woocommerce-admin'
),
title: __( 'Compare Variations', 'woocommerce-admin' ),
update: __( 'Compare', 'woocommerce-admin' ),
},
},
},
{
label: __( 'Advanced Filters', 'woocommerce-admin' ),
value: 'advanced',
},
],
},
] );
export const advancedFilters = applyFilters(
VARIATIONS_REPORT_ADVANCED_FILTERS_FILTER,
{
title: _x(
'Variations Match {{select /}} Filters',
'A sentence describing filters for Variations. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ',
'woocommerce-admin'
),
filters: {
attribute: {
allowMultiple: true,
labels: {
add: __( 'Attribute', 'woocommerce-admin' ),
placeholder: __( 'Search attributes', 'woocommerce-admin' ),
remove: __(
'Remove attribute filter',
'woocommerce-admin'
),
rule: __(
'Select a product attribute filter match',
'woocommerce-admin'
),
/* translators: A sentence describing a Product filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
title: __(
'{{title}}Attribute{{/title}} {{rule /}} {{filter /}}',
'woocommerce-admin'
),
filter: __( 'Select attributes', 'woocommerce-admin' ),
},
rules: [
{
value: 'is',
/* translators: Sentence fragment, logical, "Is" refers to searching for product variations matching a chosen attribute. Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
label: _x(
'Is',
'product attribute',
'woocommerce-admin'
),
},
{
value: 'is_not',
/* translators: Sentence fragment, logical, "Is Not" refers to searching for product variations that don\'t match a chosen attribute. Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
label: _x(
'Is Not',
'product attribute',
'woocommerce-admin'
),
},
],
input: {
component: 'ProductAttribute',
},
},
category: {
labels: {
add: __( 'Categories', 'woocommerce-admin' ),
placeholder: __( 'Search categories', 'woocommerce-admin' ),
remove: __(
'Remove categories filter',
'woocommerce-admin'
),
rule: __(
'Select a category filter match',
'woocommerce-admin'
),
/* translators: A sentence describing a Category filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
title: __(
'{{title}}Category{{/title}} {{rule /}} {{filter /}}',
'woocommerce-admin'
),
filter: __( 'Select categories', 'woocommerce-admin' ),
},
rules: [
{
value: 'includes',
/* translators: Sentence fragment, logical, "Includes" refers to variations including a given category. Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
label: _x(
'Includes',
'categories',
'woocommerce-admin'
),
},
{
value: 'excludes',
/* translators: Sentence fragment, logical, "Excludes" refers to variations excluding a given category. Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
label: _x(
'Excludes',
'categories',
'woocommerce-admin'
),
},
],
input: {
component: 'Search',
type: 'categories',
getLabels: getCategoryLabels,
},
},
product: {
labels: {
add: __( 'Products', 'woocommerce-admin' ),
placeholder: __( 'Search products', 'woocommerce-admin' ),
remove: __( 'Remove products filter', 'woocommerce-admin' ),
rule: __(
'Select a product filter match',
'woocommerce-admin'
),
/* translators: A sentence describing a Product filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
title: __(
'{{title}}Product{{/title}} {{rule /}} {{filter /}}',
'woocommerce-admin'
),
filter: __( 'Select products', 'woocommerce-admin' ),
},
rules: [
{
value: 'includes',
/* translators: Sentence fragment, logical, "Includes" refers to orders including a given product(s). Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
label: _x(
'Includes',
'products',
'woocommerce-admin'
),
},
{
value: 'excludes',
/* translators: Sentence fragment, logical, "Excludes" refers to orders excluding a given product(s). Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
label: _x(
'Excludes',
'products',
'woocommerce-admin'
),
},
],
input: {
component: 'Search',
type: 'variableProducts',
getLabels: getProductLabels,
},
},
},
}
);

View File

@ -0,0 +1,94 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import { advancedFilters, charts, filters } from './config';
import getSelectedChart from '../../../lib/get-selected-chart';
import ReportChart from '../../components/report-chart';
import ReportError from '../../components/report-error';
import ReportSummary from '../../components/report-summary';
import VariationsReportTable from './table';
import ReportFilters from '../../components/report-filters';
const getChartMeta = ( { query } ) => {
const isCompareView =
query[ 'filter-variations' ] === 'compare-variations' &&
query.variations &&
query.variations.split( ',' ).length > 1;
return {
compareObject: 'variations',
itemsLabel: __( '%d variations', 'woocommerce-admin' ),
mode: isCompareView ? 'item-comparison' : 'time-comparison',
};
};
const VariationsReport = ( props ) => {
const { itemsLabel, mode } = getChartMeta( props );
const { path, query, isError, isRequesting } = props;
if ( isError ) {
return <ReportError isError />;
}
const chartQuery = {
...query,
};
if ( mode === 'item-comparison' ) {
chartQuery.segmentby = 'variation';
}
return (
<Fragment>
<ReportFilters
query={ query }
path={ path }
filters={ filters }
advancedFilters={ advancedFilters }
report="variations"
/>
<ReportSummary
mode={ mode }
charts={ charts }
endpoint="variations"
isRequesting={ isRequesting }
query={ chartQuery }
selectedChart={ getSelectedChart( query.chart, charts ) }
filters={ filters }
advancedFilters={ advancedFilters }
/>
<ReportChart
charts={ charts }
mode={ mode }
filters={ filters }
advancedFilters={ advancedFilters }
endpoint="variations"
isRequesting={ isRequesting }
itemsLabel={ itemsLabel }
path={ path }
query={ chartQuery }
selectedChart={ getSelectedChart( chartQuery.chart, charts ) }
/>
<VariationsReportTable
isRequesting={ isRequesting }
query={ query }
filters={ filters }
advancedFilters={ advancedFilters }
/>
</Fragment>
);
};
VariationsReport.propTypes = {
path: PropTypes.string.isRequired,
query: PropTypes.object.isRequired,
};
export default VariationsReport;

View File

@ -3,7 +3,7 @@
*/
import { __, _n, _x } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { map, get } from 'lodash';
import { map } from 'lodash';
import { Link } from '@woocommerce/components';
import { getNewPath, getPersistedQuery } from '@woocommerce/navigation';
import { formatValue } from '@woocommerce/number';
@ -13,18 +13,15 @@ import { getAdminLink, getSetting } from '@woocommerce/wc-admin-settings';
* Internal dependencies
*/
import ReportTable from '../../components/report-table';
import { isLowStock } from './utils';
import { isLowStock } from '../products/utils';
import { CurrencyContext } from '../../../lib/currency-context';
import { getVariationName } from '../../../lib/async-requests';
const manageStock = getSetting( 'manageStock', 'no' );
const stockStatuses = getSetting( 'stockStatuses', {} );
const getFullVariationName = ( rowData ) =>
get( rowData, [ 'extended_info', 'name' ], '' ) +
' - ' +
get( rowData, [ 'extended_info', 'attributes' ], [] )
.map( ( { option } ) => option )
.join( ', ' );
getVariationName( rowData.extended_info || {} );
class VariationsReportTable extends Component {
constructor() {
@ -102,6 +99,7 @@ class VariationsReportTable extends Component {
net_revenue: netRevenue,
orders_count: ordersCount,
product_id: productId,
variation_id: variationId,
} = row;
const extendedInfo = row.extended_info || {};
const {
@ -116,7 +114,7 @@ class VariationsReportTable extends Component {
'/analytics/orders',
{
filter: 'advanced',
product_includes: query.products,
variation_includes: variationId,
}
);
const editPostLink = getAdminLink(
@ -253,8 +251,8 @@ class VariationsReportTable extends Component {
return (
<ReportTable
baseSearchQuery={ baseSearchQuery }
compareBy={ 'variations' }
compareParam={ 'filter-variations' }
compareBy="variations"
compareParam="filter-variations"
endpoint="variations"
getHeadersContent={ this.getHeadersContent }
getRowsContent={ this.getRowsContent }
@ -269,7 +267,6 @@ class VariationsReportTable extends Component {
'net_revenue',
'orders_count',
] }
searchBy="variations"
tableQuery={ {
orderby: query.orderby || 'items_sold',
order: query.order || 'desc',

View File

@ -6,6 +6,7 @@ import apiFetch from '@wordpress/api-fetch';
import { identity } from 'lodash';
import { getIdsFromQuery } from '@woocommerce/navigation';
import { NAMESPACE } from '@woocommerce/data';
import { getSetting } from '@woocommerce/wc-admin-settings';
/**
* Internal dependencies
@ -77,14 +78,42 @@ export const getTaxRateLabels = getRequestByIdString(
} )
);
/**
* Create a variation name by concatenating each of the variation's
* attribute option strings.
*
* @param {Object} variation - variation returned by the api
* @param {Array} variation.attributes - attribute objects, with option property.
* @param {string} variation.name - name of variation.
* @return {string} - formatted variation name
*/
export function getVariationName( { attributes, name } ) {
const separator = getSetting( 'variationTitleAttributesSeparator', ' - ' );
if ( name.indexOf( separator ) > -1 ) {
return name;
}
const attributeList = attributes
.map( ( { option } ) => option )
.join( ', ' );
return attributeList ? name + separator + attributeList : name;
}
export const getVariationLabels = getRequestByIdString(
( query ) => NAMESPACE + `/products/${ query.products }/variations`,
( { products } ) => {
// If a product was specified, get just its variations.
if ( products ) {
return NAMESPACE + `/products/${ products }/variations`;
}
return NAMESPACE + '/variations';
},
( variation ) => {
return {
key: variation.id,
label: variation.attributes
.map( ( { option } ) => option )
.join( ', ' ),
label: getVariationName( variation ),
};
}
);

View File

@ -0,0 +1,74 @@
# Variations Report
The Variations Report provides insight into the sales performance of each Product Variation on your store.
### All Variations View
By default, the Variations Report displays the All Variations view. All Variations that have had sales in the specified date range will be shown.
![Variations Report All Variations View](images/analytics-variations-report.png)
### Single Variation View
![Variations Report Single Variation Search](images/analytics-variations-report-single-variation-search.png)
By selecting "Single Variation", you can search for a single variation to display report data for.
![Variations Report Single Variation View](images/analytics-variations-report-single-variation.png)
### Comparison Mode
![Variations Report Comparison Mode Search](images/analytics-variations-report-comparison-search.png)
By selecting "Comparison", you can search for multiple variations to display report data for.
![Variations Report Comparison Mode](images/analytics-variations-report-comparison.png)
You can also use the checkboxes in the report table to select variations for comparison. Click "Compare" in the table header to compare the selected variations.
### Advanced Filters
The advanced filters allow adding multiple filters to the report. These filters can be applied in two ways:
- All - Variations must match all filters to be included in the report
- Any - Variations must match one or more filters to be included in the report
![Orders Report Filter Matching](images/analytics-variations-filter-match.png)
The following fields can be used for filtering:
- Attribute (can be used multiple times)
- Product (parent product)
- Category
![Variations Report Advanced Filters](images/analytics-variations-report-advanced-filters.png)
### Report Columns
The report table contains the following columns:
- Variation Title - links to Edit Product screen
- SKU
- Items Sold (count)
- Net Sales
- Orders (count) - links to Orders Report filtered by Variation
- Status (in/out stock)
- Stock (inventory quantity)
### Report Sorting
The report table allows sorting by the following columns:
- SKU
- Items Sold (count)
- Net Sales
- Orders (count)
By default, the report sorts Variations by most items sold.
#### Clarifying Terms
"Net Sales" is calculated by subtracting refunds and coupons from the sale price of the variation(s).
As an equation, it might look like: `(variation price * quantity) - refunds - coupons`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@ -11,4 +11,5 @@ export { default as orders } from './orders';
export { default as product } from './product';
export { default as taxes } from './taxes';
export { default as usernames } from './usernames';
export { default as variableProduct } from './variable-product';
export { default as variations } from './variations';

View File

@ -0,0 +1,109 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import productsAutocompleter from './product';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A variable products completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
...productsAutocompleter,
name: 'products',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'popularity',
type: 'variable',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products', query ),
} );
},
};

View File

@ -93,41 +93,69 @@ import ProductImage from '../../product-image';
* attribute option strings.
*
* @param {Object} variation - variation returned by the api
* @return {string} - variation name
* @param {Array} variation.attributes - attribute objects, with option property.
* @param {string} variation.name - name of variation.
* @return {string} - formatted variation name
*/
function getVariationName( variation ) {
return variation.attributes.map( ( { option } ) => option ).join( ', ' );
function getVariationName( { attributes, name } ) {
const separator =
window.wcSettings.variationTitleAttributesSeparator || ' - ';
if ( name.indexOf( separator ) > -1 ) {
return name;
}
const attributeList = attributes
.map( ( { option } ) => option )
.join( ', ' );
return attributeList ? name + separator + attributeList : name;
}
/**
* A products completer.
* A variations completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'products',
name: 'variations',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 30,
_fields: [ 'id', 'sku', 'description', 'attributes' ],
_fields: [
'attributes',
'description',
'id',
'name',
'sku',
],
}
: {};
const product = getQuery().products;
if ( ! product || product.includes( ',' ) ) {
// eslint-disable-next-line no-console
console.warn(
'Invalid product id supplied to Variations autocompleter'
);
// Product was specified, search only its variations.
if ( product ) {
if ( product.includes( ',' ) ) {
// eslint-disable-next-line no-console
console.warn(
'Invalid product id supplied to Variations autocompleter'
);
}
return apiFetch( {
path: addQueryArgs(
`/wc-analytics/products/${ product }/variations`,
query
),
} );
}
// Product was not specified, search all variations.
return apiFetch( {
path: addQueryArgs(
`/wc-analytics/products/${ product }/variations`,
query
),
path: addQueryArgs( '/wc-analytics/variations', query ),
} );
},
isDebounced: true,

View File

@ -21,6 +21,7 @@ import {
productCategory,
taxes,
usernames,
variableProduct,
variations,
} from './autocompleters';
@ -61,6 +62,8 @@ export class Search extends Component {
return taxes;
case 'usernames':
return usernames;
case 'variableProducts':
return variableProduct;
case 'variations':
return variations;
case 'custom':
@ -209,6 +212,7 @@ Search.propTypes = {
'products',
'taxes',
'usernames',
'variableProducts',
'variations',
'custom',
] ).isRequired,

View File

@ -74,6 +74,7 @@ class Init {
'Automattic\WooCommerce\Admin\API\Reports\Products\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Variations\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Orders\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Controller',
@ -117,23 +118,24 @@ class Init {
return array_merge(
$data_stores,
array(
'report-revenue-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore',
'report-orders' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore',
'report-orders-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore',
'report-products' => 'Automattic\WooCommerce\Admin\API\Reports\Products\DataStore',
'report-variations' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore',
'report-products-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\DataStore',
'report-categories' => 'Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore',
'report-taxes' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore',
'report-taxes-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore',
'report-coupons' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore',
'report-coupons-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\DataStore',
'report-downloads' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore',
'report-downloads-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\DataStore',
'admin-note' => 'Automattic\WooCommerce\Admin\Notes\DataStore',
'report-customers' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore',
'report-customers-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\DataStore',
'report-stock-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\DataStore',
'report-revenue-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore',
'report-orders' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore',
'report-orders-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore',
'report-products' => 'Automattic\WooCommerce\Admin\API\Reports\Products\DataStore',
'report-variations' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore',
'report-products-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\DataStore',
'report-variations-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\DataStore',
'report-categories' => 'Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore',
'report-taxes' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore',
'report-taxes-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore',
'report-coupons' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore',
'report-coupons-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\DataStore',
'report-downloads' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore',
'report-downloads-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\DataStore',
'admin-note' => 'Automattic\WooCommerce\Admin\Notes\DataStore',
'report-customers' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore',
'report-customers-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\DataStore',
'report-stock-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\DataStore',
)
);
}

View File

@ -22,6 +22,28 @@ class ProductVariations extends \WC_REST_Product_Variations_Controller {
*/
protected $namespace = 'wc-analytics';
/**
* Register the routes for products.
*/
public function register_routes() {
parent::register_routes();
// Add a route for listing variations without specifying the parent product ID.
register_rest_route(
$this->namespace,
'/variations',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Get the query params for collections.
*
@ -104,6 +126,11 @@ class ProductVariations extends \WC_REST_Product_Variations_Controller {
unset( $args['s'] );
}
// Retreive variations without specifying a parent product.
if ( "/{$this->namespace}/variations" === $request->get_route() ) {
unset( $args['post_parent'] );
}
return $args;
}

View File

@ -40,18 +40,18 @@ class Controller extends ReportsController implements ExportableInterface {
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['extended_info'] = $request['extended_info'];
$args['categories'] = (array) $request['categories'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['extended_info'] = $request['extended_info'];
$args['category_includes'] = (array) $request['categories'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
return $args;
}

View File

@ -107,12 +107,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$this->add_order_by_params( $query_args, 'inner', "{$wpdb->wc_category_lookup}.category_tree_id" );
}
// @todo Only products in the category C or orders with products from category C (and, possibly others?).
$included_products = $this->get_included_products( $query_args );
if ( $included_products ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" );
}
$this->add_order_status_clause( $query_args, $order_product_lookup_table, $this->subquery );
$this->subquery->add_sql_clause( 'where', "AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL" );
}
@ -167,8 +161,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
* @return string
*/
protected function get_included_categories_array( $query_args ) {
if ( isset( $query_args['categories'] ) && is_array( $query_args['categories'] ) && count( $query_args['categories'] ) > 0 ) {
return $query_args['categories'];
if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) {
return $query_args['category_includes'];
}
return array();
}
@ -215,15 +209,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'categories' => array(),
'extended_info' => false,
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'category_includes' => array(),
'extended_info' => false,
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );

View File

@ -93,6 +93,14 @@ class Controller extends \WC_REST_Reports_Controller {
'slug' => 'products/stats',
'description' => __( 'Stats about products.', 'woocommerce-admin' ),
),
array(
'slug' => 'variations',
'description' => __( 'Variations detailed reports.', 'woocommerce-admin' ),
),
array(
'slug' => 'variations/stats',
'description' => __( 'Stats about variations.', 'woocommerce-admin' ),
),
array(
'slug' => 'categories',
'description' => __( 'Product categories detailed reports.', 'woocommerce-admin' ),

View File

@ -881,27 +881,21 @@ class DataStore extends SqlQuery {
* @return array|stdClass
*/
protected function get_products_by_cat_ids( $categories ) {
$product_categories = get_categories(
$terms = get_terms(
array(
'hide_empty' => 0,
'taxonomy' => 'product_cat',
'taxonomy' => 'product_cat',
'include' => $categories,
)
);
$cat_slugs = array();
$categories = array_flip( $categories );
foreach ( $product_categories as $product_cat ) {
if ( key_exists( $product_cat->cat_ID, $categories ) ) {
$cat_slugs[] = $product_cat->slug;
}
}
if ( empty( $cat_slugs ) ) {
if ( is_wp_error( $terms ) || empty( $terms ) ) {
return array();
}
$args = array(
'category' => $cat_slugs,
'category' => wc_list_pluck( $terms, 'slug' ),
'limit' => -1,
'return' => 'ids',
);
return wc_get_products( $args );
}
@ -945,18 +939,24 @@ class DataStore extends SqlQuery {
$included_products = array();
$operator = $this->get_match_operator( $query_args );
if ( isset( $query_args['categories'] ) && is_array( $query_args['categories'] ) && count( $query_args['categories'] ) > 0 ) {
$included_products = $this->get_products_by_cat_ids( $query_args['categories'] );
$included_products = empty( $included_products ) ? array( '-1' ) : wc_list_pluck( $included_products, 'get_id' );
if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) {
$included_products = $this->get_products_by_cat_ids( $query_args['category_includes'] );
// If no products were found in the specified categories, we will force an empty set
// by matching a product ID of -1, unless the filters are OR/any and products are specified.
if ( empty( $included_products ) ) {
$included_products = array( '-1' );
}
}
if ( isset( $query_args['product_includes'] ) && is_array( $query_args['product_includes'] ) && count( $query_args['product_includes'] ) > 0 ) {
if ( count( $included_products ) > 0 ) {
if ( 'AND' === $operator ) {
// AND results in an intersection between products from selected categories and manually included products.
$included_products = array_intersect( $included_products, $query_args['product_includes'] );
} elseif ( 'OR' === $operator ) {
// Union of products from selected categories and manually included products.
$included_products = array_unique( array_merge( $included_products, $query_args['product_includes'] ) );
// OR results in a union of products from selected categories and manually included products.
$included_products = array_merge( $included_products, $query_args['product_includes'] );
}
} else {
$included_products = $query_args['product_includes'];
@ -984,11 +984,38 @@ class DataStore extends SqlQuery {
* @return string
*/
protected function get_included_variations( $query_args ) {
if ( isset( $query_args['variations'] ) && is_array( $query_args['variations'] ) && count( $query_args['variations'] ) > 0 ) {
$query_args['variations'] = array_filter( array_map( 'intval', $query_args['variations'] ) );
return $this->get_filtered_ids( $query_args, 'variation_includes' );
}
/**
* Returns comma separated ids of excluded variations, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_variations( $query_args ) {
return $this->get_filtered_ids( $query_args, 'variation_excludes' );
}
/**
* Returns an array of ids of disallowed products, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_excluded_products_array( $query_args ) {
$excluded_products = array();
$operator = $this->get_match_operator( $query_args );
if ( isset( $query_args['category_excludes'] ) && is_array( $query_args['category_excludes'] ) && count( $query_args['category_excludes'] ) > 0 ) {
$excluded_products = $this->get_products_by_cat_ids( $query_args['category_excludes'] );
}
return $this->get_filtered_ids( $query_args, 'variations' );
if ( isset( $query_args['product_excludes'] ) && is_array( $query_args['product_excludes'] ) && count( $query_args['product_excludes'] ) > 0 ) {
$excluded_products = array_merge( $excluded_products, $query_args['product_excludes'] );
}
return $excluded_products;
}
/**
@ -998,7 +1025,8 @@ class DataStore extends SqlQuery {
* @return string
*/
protected function get_excluded_products( $query_args ) {
return $this->get_filtered_ids( $query_args, 'product_excludes' );
$excluded_products = $this->get_excluded_products_array( $query_args );
return implode( ',', $excluded_products );
}
/**
@ -1008,7 +1036,7 @@ class DataStore extends SqlQuery {
* @return string
*/
protected function get_included_categories( $query_args ) {
return $this->get_filtered_ids( $query_args, 'categories' );
return $this->get_filtered_ids( $query_args, 'category_includes' );
}
/**

View File

@ -40,29 +40,31 @@ class Controller extends ReportsController implements ExportableInterface {
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['product_includes'] = (array) $request['product_includes'];
$args['product_excludes'] = (array) $request['product_excludes'];
$args['coupon_includes'] = (array) $request['coupon_includes'];
$args['coupon_excludes'] = (array) $request['coupon_excludes'];
$args['tax_rate_includes'] = (array) $request['tax_rate_includes'];
$args['tax_rate_excludes'] = (array) $request['tax_rate_excludes'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
$args['customer_type'] = $request['customer_type'];
$args['extended_info'] = $request['extended_info'];
$args['refunds'] = $request['refunds'];
$args['match'] = $request['match'];
$args['order_includes'] = $request['order_includes'];
$args['order_excludes'] = $request['order_excludes'];
$args['attribute_is'] = (array) $request['attribute_is'];
$args['attribute_is_not'] = (array) $request['attribute_is_not'];
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['product_includes'] = (array) $request['product_includes'];
$args['product_excludes'] = (array) $request['product_excludes'];
$args['variation_includes'] = (array) $request['variation_includes'];
$args['variation_excludes'] = (array) $request['variation_excludes'];
$args['coupon_includes'] = (array) $request['coupon_includes'];
$args['coupon_excludes'] = (array) $request['coupon_excludes'];
$args['tax_rate_includes'] = (array) $request['tax_rate_includes'];
$args['tax_rate_excludes'] = (array) $request['tax_rate_excludes'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
$args['customer_type'] = $request['customer_type'];
$args['extended_info'] = $request['extended_info'];
$args['refunds'] = $request['refunds'];
$args['match'] = $request['match'];
$args['order_includes'] = $request['order_includes'];
$args['order_excludes'] = $request['order_excludes'];
$args['attribute_is'] = (array) $request['attribute_is'];
$args['attribute_is_not'] = (array) $request['attribute_is_not'];
return $args;
}
@ -253,9 +255,9 @@ class Controller extends ReportsController implements ExportableInterface {
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce-admin' ),
'type' => 'integer',
'default' => 1,
@ -263,7 +265,7 @@ class Controller extends ReportsController implements ExportableInterface {
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce-admin' ),
'type' => 'integer',
'default' => 10,
@ -272,26 +274,26 @@ class Controller extends ReportsController implements ExportableInterface {
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'date',
@ -302,7 +304,7 @@ class Controller extends ReportsController implements ExportableInterface {
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_includes'] = array(
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -312,7 +314,7 @@ class Controller extends ReportsController implements ExportableInterface {
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_excludes'] = array(
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -322,7 +324,27 @@ class Controller extends ReportsController implements ExportableInterface {
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_includes'] = array(
$params['variation_includes'] = array(
'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['variation_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_includes'] = array(
'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -332,7 +354,7 @@ class Controller extends ReportsController implements ExportableInterface {
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['coupon_excludes'] = array(
$params['coupon_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -342,7 +364,7 @@ class Controller extends ReportsController implements ExportableInterface {
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['tax_rate_includes'] = array(
$params['tax_rate_includes'] = array(
'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -352,7 +374,7 @@ class Controller extends ReportsController implements ExportableInterface {
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['tax_rate_excludes'] = array(
$params['tax_rate_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -362,7 +384,7 @@ class Controller extends ReportsController implements ExportableInterface {
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['status_is'] = array(
$params['status_is'] = array(
'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
@ -372,7 +394,7 @@ class Controller extends ReportsController implements ExportableInterface {
'type' => 'string',
),
);
$params['status_is_not'] = array(
$params['status_is_not'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
@ -382,7 +404,7 @@ class Controller extends ReportsController implements ExportableInterface {
'type' => 'string',
),
);
$params['customer_type'] = array(
$params['customer_type'] = array(
'description' => __( 'Limit result set to returning or new customers.', 'woocommerce-admin' ),
'type' => 'string',
'default' => '',
@ -393,7 +415,7 @@ class Controller extends ReportsController implements ExportableInterface {
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['refunds'] = array(
$params['refunds'] = array(
'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce-admin' ),
'type' => 'string',
'default' => '',
@ -406,14 +428,14 @@ class Controller extends ReportsController implements ExportableInterface {
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['extended_info'] = array(
$params['extended_info'] = array(
'description' => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce-admin' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order_includes'] = array(
$params['order_includes'] = array(
'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
@ -422,7 +444,7 @@ class Controller extends ReportsController implements ExportableInterface {
'type' => 'integer',
),
);
$params['order_excludes'] = array(
$params['order_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
@ -431,7 +453,7 @@ class Controller extends ReportsController implements ExportableInterface {
'type' => 'integer',
),
);
$params['attribute_is'] = array(
$params['attribute_is'] = array(
'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -440,7 +462,7 @@ class Controller extends ReportsController implements ExportableInterface {
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is_not'] = array(
$params['attribute_is_not'] = array(
'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(

View File

@ -89,6 +89,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$order_tax_lookup_table = $wpdb->prefix . 'wc_order_tax_lookup';
$operator = $this->get_match_operator( $query_args );
$where_subquery = array();
$have_joined_products_table = false;
$this->add_time_period_sql_params( $query_args, $order_stats_lookup_table );
$this->get_limit_sql_params( $query_args );
@ -139,6 +140,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$included_products = $this->get_included_products( $query_args );
$excluded_products = $this->get_excluded_products( $query_args );
if ( $included_products || $excluded_products ) {
$have_joined_products_table = true;
$this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" );
}
if ( $included_products ) {
@ -148,6 +150,19 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$where_subquery[] = "{$order_product_lookup_table}.product_id NOT IN ({$excluded_products})";
}
$included_variations = $this->get_included_variations( $query_args );
$excluded_variations = $this->get_excluded_variations( $query_args );
if ( ! $have_joined_products_table && ( $included_variations || $excluded_variations ) ) {
$have_joined_products_table = true;
$this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" );
}
if ( $included_variations ) {
$where_subquery[] = "{$order_product_lookup_table}.variation_id IN ({$included_variations})";
}
if ( $excluded_variations ) {
$where_subquery[] = "{$order_product_lookup_table}.variation_id NOT IN ({$excluded_variations})";
}
$included_tax_rates = ! empty( $query_args['tax_rate_includes'] ) ? implode( ',', $query_args['tax_rate_includes'] ) : false;
$excluded_tax_rates = ! empty( $query_args['tax_rate_excludes'] ) ? implode( ',', $query_args['tax_rate_excludes'] ) : false;
if ( $included_tax_rates || $excluded_tax_rates ) {
@ -163,7 +178,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$attribute_subqueries = $this->get_attribute_subqueries( $query_args );
if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
// JOIN on product lookup if we haven't already.
if ( ! $included_products && ! $excluded_products ) {
if ( ! $have_joined_products_table ) {
$have_joined_products_table = true;
$this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" );
}
// Add JOINs for matching attributes.
@ -319,11 +335,24 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$mapped_data[ $product['order_id'] ]['products'] = array();
}
$mapped_data[ $product['order_id'] ]['products'][] = array(
'id' => '0' === $product['variation_id'] ? $product['product_id'] : $product['variation_id'],
$is_variation = '0' !== $product['variation_id'];
$product_data = array(
'id' => $is_variation ? $product['variation_id'] : $product['product_id'],
'name' => $product['product_name'],
'quantity' => $product['product_quantity'],
);
if ( $is_variation ) {
$variation = wc_get_product( $product_data['id'] );
$separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $variation );
if ( false === strpos( $product_data['name'], $separator ) ) {
$attributes = wc_get_formatted_variation( $variation, true, false );
$product_data['name'] .= $separator . $attributes;
}
}
$mapped_data[ $product['order_id'] ]['products'][] = $product_data;
}
foreach ( $coupons as $coupon ) {

View File

@ -39,30 +39,32 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['fields'] = $request['fields'];
$args['match'] = $request['match'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
$args['product_includes'] = (array) $request['product_includes'];
$args['product_excludes'] = (array) $request['product_excludes'];
$args['coupon_includes'] = (array) $request['coupon_includes'];
$args['coupon_excludes'] = (array) $request['coupon_excludes'];
$args['tax_rate_includes'] = (array) $request['tax_rate_includes'];
$args['tax_rate_excludes'] = (array) $request['tax_rate_excludes'];
$args['customer'] = $request['customer'];
$args['refunds'] = $request['refunds'];
$args['attribute_is'] = (array) $request['attribute_is'];
$args['attribute_is_not'] = (array) $request['attribute_is_not'];
$args['categories'] = (array) $request['categories'];
$args['segmentby'] = $request['segmentby'];
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['fields'] = $request['fields'];
$args['match'] = $request['match'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
$args['product_includes'] = (array) $request['product_includes'];
$args['product_excludes'] = (array) $request['product_excludes'];
$args['variation_includes'] = (array) $request['variation_includes'];
$args['variation_excludes'] = (array) $request['variation_excludes'];
$args['coupon_includes'] = (array) $request['coupon_includes'];
$args['coupon_excludes'] = (array) $request['coupon_excludes'];
$args['tax_rate_includes'] = (array) $request['tax_rate_includes'];
$args['tax_rate_excludes'] = (array) $request['tax_rate_excludes'];
$args['customer'] = $request['customer'];
$args['refunds'] = $request['refunds'];
$args['attribute_is'] = (array) $request['attribute_is'];
$args['attribute_is_not'] = (array) $request['attribute_is_not'];
$args['category_includes'] = (array) $request['categories'];
$args['segmentby'] = $request['segmentby'];
return $args;
}
@ -425,7 +427,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'sanitize_callback' => 'wp_parse_id_list',
);
$params['product_excludes'] = array(
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -434,7 +436,27 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_includes'] = array(
$params['variation_includes'] = array(
'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['variation_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_includes'] = array(
'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -443,7 +465,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_excludes'] = array(
$params['coupon_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -452,7 +474,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['tax_rate_includes'] = array(
$params['tax_rate_includes'] = array(
'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -462,7 +484,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['tax_rate_excludes'] = array(
$params['tax_rate_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -472,7 +494,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['customer'] = array(
$params['customer'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce-admin' ),
'type' => 'string',
'enum' => array(
@ -481,7 +503,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['refunds'] = array(
$params['refunds'] = array(
'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce-admin' ),
'type' => 'string',
'default' => '',
@ -494,7 +516,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is'] = array(
$params['attribute_is'] = array(
'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -503,7 +525,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is_not'] = array(
$params['attribute_is_not'] = array(
'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
@ -512,7 +534,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['segmentby'] = array(
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'woocommerce-admin' ),
'type' => 'string',
'enum' => array(
@ -524,7 +546,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['fields'] = array(
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',

View File

@ -142,6 +142,24 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$this->get_excluded_products( $query_args )
);
// Variations filters.
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$product_lookup,
'variation_id',
'IN',
$this->get_included_variations( $query_args )
);
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$product_lookup,
'variation_id',
'NOT IN',
$this->get_excluded_variations( $query_args )
);
// Coupons filters.
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
@ -262,7 +280,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'tax_rate_includes' => array(),
'tax_rate_excludes' => array(),
'customer' => '',
'categories' => array(),
'category_includes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );

View File

@ -38,7 +38,9 @@ class Controller extends \WC_REST_Reports_Controller implements ExportableInterf
* @var array
*/
protected $param_mapping = array(
'products' => 'product_includes',
'categories' => 'category_includes',
'products' => 'product_includes',
'variations' => 'variation_includes',
);
/**

View File

@ -266,16 +266,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'categories' => array(),
'product_includes' => array(),
'extended_info' => false,
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'category_includes' => array(),
'product_includes' => array(),
'extended_info' => false,
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );

View File

@ -38,7 +38,9 @@ class Controller extends \WC_REST_Reports_Controller {
* @var array
*/
protected $param_mapping = array(
'products' => 'product_includes',
'categories' => 'category_includes',
'products' => 'product_includes',
'variations' => 'variation_includes',
);
/**

View File

@ -106,16 +106,16 @@ class DataStore extends ProductsDataStore implements DataStoreInterface {
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'categories' => array(),
'interval' => 'week',
'product_includes' => array(),
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'category_includes' => array(),
'interval' => 'week',
'product_includes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );

View File

@ -196,7 +196,7 @@ class Segmenter extends ReportsSegmenter {
$segmenting_dimension_name = 'category_id';
// Restrict our search space for category comparisons.
if ( isset( $this->query_args['categories'] ) ) {
if ( isset( $this->query_args['category_includes'] ) ) {
$category_ids = implode( ',', $this->get_all_segments() );
$segmenting_where .= " AND {$wpdb->wc_category_lookup}.category_id IN ( $category_ids )";
}

View File

@ -344,12 +344,12 @@ class Segmenter {
$args['include'] = $this->query_args['product_includes'];
}
if ( isset( $this->query_args['categories'] ) ) {
$categories = $this->query_args['categories'];
if ( isset( $this->query_args['category_includes'] ) ) {
$categories = $this->query_args['category_includes'];
$args['category'] = array();
foreach ( $categories as $category_id ) {
$terms = get_term_by( 'id', $category_id, 'product_cat' );
$args['category'] = $terms->slug;
$terms = get_term_by( 'id', $category_id, 'product_cat' );
$args['category'][] = $terms->slug;
}
}
@ -360,21 +360,21 @@ class Segmenter {
$segment_labels[ $id ] = $segment->get_name();
}
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
// @todo Assuming that this will only be used for one product, check assumption.
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
$this->all_segment_ids = array();
return;
}
$args = array(
'return' => 'objects',
'limit' => -1,
'type' => 'variation',
'parent' => $this->query_args['product_includes'][0],
);
if ( isset( $this->query_args['variations'] ) ) {
$args['include'] = $this->query_args['variations'];
if (
isset( $this->query_args['product_includes'] ) &&
count( $this->query_args['product_includes'] ) === 1
) {
$args['parent'] = $this->query_args['product_includes'][0];
}
if ( isset( $this->query_args['variation_includes'] ) ) {
$args['include'] = $this->query_args['variation_includes'];
}
$segment_objects = wc_get_products( $args );
@ -392,8 +392,8 @@ class Segmenter {
// If no variations were specified, add a segment for the parent product (variation = 0).
// This is to catch simple products with prior sales converted into variable products.
// See: https://github.com/woocommerce/woocommerce-admin/issues/2719.
if ( empty( $this->query_args['variations'] ) ) {
$parent_object = wc_get_product( $this->query_args['product_includes'][0] );
if ( isset( $args['parent'] ) && empty( $args['include'] ) ) {
$parent_object = wc_get_product( $args['parent'] );
$segments[] = 0;
$segment_labels[0] = $parent_object->get_name();
}
@ -402,8 +402,8 @@ class Segmenter {
'taxonomy' => 'product_cat',
);
if ( isset( $this->query_args['categories'] ) ) {
$args['include'] = $this->query_args['categories'];
if ( isset( $this->query_args['category_includes'] ) ) {
$args['include'] = $this->query_args['category_includes'];
}
// @todo: Look into `wc_get_products` or data store methods and not directly touching the database or post types.

View File

@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Variations;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;
@ -17,7 +18,7 @@ use \Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;
*
* @extends WC_REST_Reports_Controller
*/
class Controller extends \WC_REST_Reports_Controller implements ExportableInterface {
class Controller extends ReportsController implements ExportableInterface {
/**
* Exportable traits.
*/
@ -43,7 +44,7 @@ class Controller extends \WC_REST_Reports_Controller implements ExportableInterf
* @var array
*/
protected $param_mapping = array(
'products' => 'product_includes',
'variations' => 'variation_includes',
);
/**
@ -252,9 +253,9 @@ class Controller extends \WC_REST_Reports_Controller implements ExportableInterf
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce-admin' ),
'type' => 'integer',
'default' => 1,
@ -262,7 +263,7 @@ class Controller extends \WC_REST_Reports_Controller implements ExportableInterf
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce-admin' ),
'type' => 'integer',
'default' => 10,
@ -271,26 +272,36 @@ class Controller extends \WC_REST_Reports_Controller implements ExportableInterf
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'date',
@ -303,16 +314,27 @@ class Controller extends \WC_REST_Reports_Controller implements ExportableInterf
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['products'] = array(
'description' => __( 'Limit result to items with specified product ids.', 'woocommerce-admin' ),
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['variations'] = array(
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['variations'] = array(
'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
@ -321,13 +343,49 @@ class Controller extends \WC_REST_Reports_Controller implements ExportableInterf
'type' => 'integer',
),
);
$params['extended_info'] = array(
'description' => __( 'Add additional piece of info about each product to the report.', 'woocommerce-admin' ),
$params['extended_info'] = array(
'description' => __( 'Add additional piece of info about each variation to the report.', 'woocommerce-admin' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is'] = array(
'description' => __( 'Limit result set to variations that include the specified attributes.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is_not'] = array(
'description' => __( 'Limit result set to variations that don\'t include the specified attributes.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['category_includes'] = array(
'description' => __( 'Limit result set to variations in the specified categories.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['category_excludes'] = array(
'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
return $params;
}

View File

@ -102,7 +102,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
}
$table_name = self::get_db_table_name();
$join = "JOIN {$wpdb->postmeta} AS postmeta ON {$table_name}.variation_id = postmeta.post_id AND postmeta.meta_key = '_sku'";
$join = "LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$table_name}.variation_id = postmeta.post_id AND postmeta.meta_key = '_sku'";
if ( 'inner' === $arg_name ) {
$this->subquery->add_sql_clause( 'join', $join );
@ -119,12 +119,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$order_product_lookup_table = self::get_db_table_name();
$order_stats_lookup_table = $wpdb->prefix . 'wc_order_stats';
$where_subquery = array();
$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
$this->get_limit_sql_params( $query_args );
$this->add_order_by_sql_params( $query_args );
if ( count( $query_args['variations'] ) > 0 ) {
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations > 0 ) {
$this->add_from_sql_params( $query_args, 'outer' );
} else {
$this->add_from_sql_params( $query_args, 'inner' );
@ -135,16 +138,41 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" );
}
if ( count( $query_args['variations'] ) > 0 ) {
$allowed_variations_str = self::get_filtered_ids( $query_args, 'variations' );
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$allowed_variations_str})" );
$excluded_products = $this->get_excluded_products( $query_args );
if ( $excluded_products ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})" );
}
if ( $included_variations ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" );
} elseif ( ! $included_products ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" );
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
$this->subquery->add_sql_clause( 'join', "JOIN {$order_stats_lookup_table} ON {$order_product_lookup_table}.order_id = {$order_stats_lookup_table}.order_id" );
$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
}
$attribute_subqueries = $this->get_attribute_subqueries( $query_args );
if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
// JOIN on product lookup if we haven't already.
if ( ! $order_status_filter ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" );
}
// Add JOINs for matching attributes.
foreach ( $attribute_subqueries['join'] as $attribute_join ) {
$this->subquery->add_sql_clause( 'join', $attribute_join );
}
// Add WHEREs for matching attributes.
$where_subquery = array_merge( $where_subquery, $attribute_subqueries['where'] );
}
if ( 0 < count( $where_subquery ) ) {
$operator = $this->get_match_operator( $query_args );
$this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' );
}
}
/**
@ -176,26 +204,32 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$extended_info = new \ArrayObject();
if ( $query_args['extended_info'] ) {
$extended_attributes = apply_filters( 'woocommerce_rest_reports_variations_extended_attributes', $this->extended_attributes, $product_data );
$product = wc_get_product( $product_data['product_id'] );
$variations = array();
$parent_product = wc_get_product( $product_data['product_id'] );
$attributes = array();
// Base extended info off the parent variable product if the variation ID is 0.
// This is caused by simple products with prior sales being converted into variable products.
// See: https://github.com/woocommerce/woocommerce-admin/issues/2719.
$variation_id = (int) $product_data['variation_id'];
$variation_product = ( 0 === $variation_id ) ? $product : wc_get_product( $variation_id );
$attributes = array();
$variation_product = ( 0 === $variation_id ) ? $parent_product : wc_get_product( $variation_id );
// Fall back to the parent product if the variation can't be found.
$extended_attributes_product = is_a( $variation_product, 'WC_Product' ) ? $variation_product : $parent_product;
foreach ( $extended_attributes as $extended_attribute ) {
$function = 'get_' . $extended_attribute;
if ( is_callable( array( $variation_product, $function ) ) ) {
$value = $variation_product->{$function}();
if ( is_callable( array( $extended_attributes_product, $function ) ) ) {
$value = $extended_attributes_product->{$function}();
$extended_info[ $extended_attribute ] = $value;
}
}
// If this is a variation, add its attributes.
if ( 0 < $variation_id ) {
// NOTE: We don't fall back to the parent product here because it will include all possible attribute options.
if (
0 < $variation_id &&
is_callable( array( $variation_product, 'get_variation_attributes' ) )
) {
$variation_attributes = $variation_product->get_variation_attributes();
foreach ( $variation_attributes as $attribute_name => $attribute ) {
@ -235,16 +269,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'products' => array(),
'variations' => array(),
'extended_info' => false,
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'product_includes' => array(),
'variation_includes' => array(),
'extended_info' => false,
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
@ -266,22 +300,28 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'page_no' => 0,
);
$included_products = $this->get_included_products_array( $query_args );
$selections = $this->selected_columns( $query_args );
$included_variations =
( isset( $query_args['variation_includes'] ) && is_array( $query_args['variation_includes'] ) )
? $query_args['variation_includes']
: array();
$params = $this->get_limit_params( $query_args );
$this->add_sql_query_params( $query_args );
$params = $this->get_limit_params( $query_args );
if ( count( $included_products ) > 0 && count( $query_args['variations'] ) > 0 ) {
$this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) );
if ( count( $included_variations ) > 0 ) {
$total_results = count( $included_variations );
$total_pages = (int) ceil( $total_results / $params['per_page'] );
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
if ( 'date' === $query_args['orderby'] ) {
$this->subquery->add_sql_clause( 'select', ", {$table_name}.date_created" );
}
$total_results = count( $query_args['variations'] );
$total_pages = (int) ceil( $total_results / $params['per_page'] );
$fields = $this->get_fields( $query_args );
$join_selections = $this->format_join_selections( $fields, array( 'product_id', 'variation_id' ) );
$ids_table = $this->get_ids_table( $query_args['variations'], 'variation_id', array( 'product_id' => $included_products[0] ) );
$join_selections = $this->format_join_selections( $fields, array( 'variation_id' ) );
$ids_table = $this->get_ids_table( $included_variations, 'variation_id' );
$this->add_sql_clause( 'select', $join_selections );
$this->add_sql_clause( 'from', '(' );
@ -309,8 +349,9 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
}
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) );
$this->subquery->add_sql_clause( 'select', $selections );
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$variations_query = $this->subquery->get_query_statement();
}

View File

@ -0,0 +1,474 @@
<?php
/**
* REST API Reports variations stats controller
*
* Handles requests to the /reports/variations/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\ParameterException;
/**
* REST API Reports variations stats controller class.
*
* @extends WC_REST_Reports_Controller
*/
class Controller extends \WC_REST_Reports_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/variations/stats';
/**
* Mapping between external parameter name and name used in query class.
*
* @var array
*/
protected $param_mapping = array(
'variations' => 'variation_includes',
);
/**
* Constructor.
*/
public function __construct() {
add_filter( 'woocommerce_analytics_variations_stats_select_query', array( $this, 'set_default_report_data' ) );
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = array(
'fields' => array(
'items_sold',
'net_revenue',
'orders_count',
'variations_count',
),
);
$registered = array_keys( $this->get_collection_params() );
foreach ( $registered as $param_name ) {
if ( isset( $request[ $param_name ] ) ) {
if ( isset( $this->param_mapping[ $param_name ] ) ) {
$query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
} else {
$query_args[ $param_name ] = $request[ $param_name ];
}
}
}
$query = new Query( $query_args );
try {
$report_data = $query->get_data();
} catch ( ParameterException $e ) {
return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
'intervals' => array(),
);
foreach ( $report_data->intervals as $interval_data ) {
$item = $this->prepare_item_for_response( $interval_data, $request );
$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
}
$response = rest_ensure_response( $out_data );
$response->header( 'X-WP-Total', (int) $report_data->total );
$response->header( 'X-WP-TotalPages', (int) $report_data->pages );
$page = $report_data->page_no;
$max_pages = $report_data->pages;
$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
if ( $page > 1 ) {
$prev_page = $page - 1;
if ( $prev_page > $max_pages ) {
$prev_page = $max_pages;
}
$prev_link = add_query_arg( 'page', $prev_page, $base );
$response->link_header( 'prev', $prev_link );
}
if ( $max_pages > $page ) {
$next_page = $page + 1;
$next_link = add_query_arg( 'page', $next_page, $base );
$response->link_header( 'next', $next_link );
}
return $response;
}
/**
* Prepare a report object for serialization.
*
* @param Array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_variations_stats', $response, $report, $request );
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$data_values = array(
'items_sold' => array(
'title' => __( 'Items Sold', 'woocommerce-admin' ),
'description' => __( 'Number of items sold.', 'woocommerce-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
),
'net_revenue' => array(
'description' => __( 'Net Sales.', 'woocommerce-admin' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
'orders_count' => array(
'description' => __( 'Number of orders.', 'woocommerce-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
$segments = array(
'segments' => array(
'description' => __( 'Reports data grouped by segment condition.', 'woocommerce-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'segment_id' => array(
'description' => __( 'Segment identificator.', 'woocommerce-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'segment_label' => array(
'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'woocommerce-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $data_values,
),
),
),
),
);
$totals = array_merge( $data_values, $segments );
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_variations_stats',
'type' => 'object',
'properties' => array(
'totals' => array(
'description' => __( 'Totals data.', 'woocommerce-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
'intervals' => array(
'description' => __( 'Reports data grouped by intervals.', 'woocommerce-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'interval' => array(
'description' => __( 'Type of interval.', 'woocommerce-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
),
'date_start' => array(
'description' => __( "The date the report start, in the site's timezone.", 'woocommerce-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_start_gmt' => array(
'description' => __( 'The date the report start, as GMT.', 'woocommerce-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end' => array(
'description' => __( "The date the report end, in the site's timezone.", 'woocommerce-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end_gmt' => array(
'description' => __( 'The date the report end, as GMT.', 'woocommerce-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'woocommerce-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Set the default results to 0 if API returns an empty array
*
* @param Mixed $results Report data.
* @return object
*/
public function set_default_report_data( $results ) {
if ( empty( $results ) ) {
$results = new \stdClass();
$results->total = 0;
$results->totals = new \stdClass();
$results->totals->items_sold = 0;
$results->totals->net_revenue = 0;
$results->totals->orders_count = 0;
$results->intervals = array();
$results->pages = 1;
$results->page_no = 1;
}
return $results;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce-admin' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce-admin' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
'net_revenue',
'coupons',
'refunds',
'shipping',
'taxes',
'net_revenue',
'orders_count',
'items_sold',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['interval'] = array(
'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce-admin' ),
'type' => 'string',
'default' => 'week',
'enum' => array(
'hour',
'day',
'week',
'month',
'quarter',
'year',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['category_includes'] = array(
'description' => __( 'Limit result to items from the specified categories.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['category_excludes'] = array(
'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['variations'] = array(
'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'woocommerce-admin' ),
'type' => 'string',
'enum' => array(
'product',
'category',
'variation',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['attribute_is'] = array(
'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is_not'] = array(
'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce-admin' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
}

View File

@ -0,0 +1,274 @@
<?php
/**
* API\Reports\Products\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore as VariationsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Variations\Stats\DataStore.
*/
class DataStore extends VariationsDataStore implements DataStoreInterface {
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'variations_count' => 'intval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'variatons_stats';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count',
);
}
/**
* Updates the database query with parameters used for Products Stats report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function update_sql_query_params( $query_args ) {
global $wpdb;
$products_where_clause = '';
$products_from_clause = '';
$where_subquery = array();
$order_product_lookup_table = self::get_db_table_name();
$included_products = $this->get_included_products( $query_args );
if ( $included_products ) {
$products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";
}
$excluded_products = $this->get_excluded_products( $query_args );
if ( $excluded_products ) {
$products_where_clause .= "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})";
}
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations ) {
$products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})";
} else {
$products_where_clause .= " AND {$order_product_lookup_table}.variation_id != 0";
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
$products_where_clause .= " AND ( {$order_status_filter} )";
}
$attribute_subqueries = $this->get_attribute_subqueries( $query_args );
if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
// JOIN on product lookup if we haven't already.
if ( ! $order_status_filter ) {
$products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
}
// Add JOINs for matching attributes.
foreach ( $attribute_subqueries['join'] as $attribute_join ) {
$products_from_clause .= ' ' . $attribute_join;
}
// Add WHEREs for matching attributes.
$where_subquery = array_merge( $where_subquery, $attribute_subqueries['where'] );
}
if ( 0 < count( $where_subquery ) ) {
$operator = $this->get_match_operator( $query_args );
$products_where_clause .= 'AND (' . implode( " {$operator} ", $where_subquery ) . ')';
}
$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
$this->total_query->add_sql_clause( 'where', $products_where_clause );
$this->total_query->add_sql_clause( 'join', $products_from_clause );
$this->add_intervals_sql_params( $query_args, $order_product_lookup_table );
$this->interval_query->add_sql_clause( 'where', $products_where_clause );
$this->interval_query->add_sql_clause( 'join', $products_from_clause );
$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @since 3.5.0
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'category_includes' => array(),
'interval' => 'week',
'product_includes' => array(),
'variation_includes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$selections = $this->selected_columns( $query_args );
$params = $this->get_limit_params( $query_args );
$this->update_sql_query_params( $query_args );
$this->get_limit_sql_params( $query_args );
$this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
$db_intervals = $wpdb->get_col(
$this->interval_query->get_query_statement()
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$db_interval_count = count( $db_intervals );
$expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
$total_pages = (int) ceil( $expected_interval_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return array();
}
$intervals = array();
$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
$this->total_query->add_sql_clause( 'select', $selections );
$this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
$totals = $wpdb->get_results(
$this->total_query->get_query_statement(),
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// @todo remove these assignements when refactoring segmenter classes to use query objects.
$totals_query = array(
'from_clause' => $this->total_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->total_query->get_sql_clause( 'where' ),
);
$intervals_query = array(
'select_clause' => $this->get_sql_clause( 'select' ),
'from_clause' => $this->interval_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->interval_query->get_sql_clause( 'where' ),
'order_by' => $this->get_sql_clause( 'order_by' ),
'limit' => $this->get_sql_clause( 'limit' ),
);
$segmenter = new Segmenter( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
if ( null === $totals ) {
return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce-admin' ) );
}
$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" );
if ( '' !== $selections ) {
$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
}
$intervals = $wpdb->get_results(
$this->interval_query->get_query_statement(),
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $intervals ) {
return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce-admin' ) );
}
$totals = (object) $this->cast_numbers( $totals[0] );
$data = (object) array(
'totals' => $totals,
'intervals' => $intervals,
'total' => $expected_interval_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
$this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] );
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
wp_cache_set( $cache_key, $data, $this->cache_group );
}
return $data;
}
/**
* Normalizes order_by clause to match to SQL query.
*
* @param string $order_by Order by option requeste by user.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
return $order_by;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
unset( $this->subquery );
$this->total_query = new SqlQuery( $this->context . '_total' );
$this->total_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query = new SqlQuery( $this->context . '_interval' );
$this->interval_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query->add_sql_clause( 'group_by', 'time_interval' );
}
}

View File

@ -0,0 +1,50 @@
<?php
/**
* Class for parameter-based Variations Stats Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'categories' => array(15, 18),
* 'product_ids' => array(1,2,3)
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Variations\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Products report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get variations data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_variations_stats_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-variations-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_variations_stats_select_query', $results, $args );
}
}

View File

@ -0,0 +1,176 @@
<?php
/**
* Class for adding segmenting support without cluttering the data stores.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter;
use \Automattic\WooCommerce\Admin\API\Reports\ParameterException;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class Segmenter extends ReportsSegmenter {
/**
* Returns column => query mapping to be used for product-related product-level segmenting query
* (e.g. products sold, revenue from product X when segmenting by category).
*
* @param string $products_table Name of SQL table containing the product-level segmenting info.
*
* @return array Column => SELECT query mapping.
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'items_sold' => "SUM($products_table.product_qty) as items_sold",
'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue",
'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count",
'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count",
);
return $columns_mapping;
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
return $totals_segments;
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// LIMIT offset, rowcount needs to be updated to a multiple of the number of segments.
preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts );
$segment_count = count( $this->get_all_segments() );
$orig_offset = intval( $limit_parts[1] );
$orig_rowcount = intval( $limit_parts[2] );
$segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count );
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
$unique_orders_table = 'uniq_orders';
$segmenting_where = '';
// Product, variation, and category are bound to product, so here product segmenting table is required,
// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
// This also means that segment selections need to be calculated differently.
if ( 'variation' === $this->query_args['segmentby'] ) {
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
);
$this->report_columns = $product_level_columns;
$segmenting_from = '';
$segmenting_groupby = $product_segmenting_table . '.variation_id';
$segmenting_dimension_name = 'variation_id';
// Restrict our search space for variation comparisons.
if ( isset( $this->query_args['variation_includes'] ) ) {
$variation_ids = implode( ',', $this->get_all_segments() );
$segmenting_where = " AND $product_segmenting_table.variation_id IN ( $variation_ids )";
}
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
}
return $segments;
}
}

View File

@ -145,6 +145,12 @@ class Analytics {
'parent' => 'woocommerce-analytics',
'path' => '/analytics/products',
),
array(
'id' => 'woocommerce-analytics-variations',
'title' => __( 'Variations', 'woocommerce-admin' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/variations',
),
array(
'id' => 'woocommerce-analytics-categories',
'title' => __( 'Categories', 'woocommerce-admin' ),

View File

@ -1048,6 +1048,8 @@ class Loader {
// We may have synced orders with a now-unregistered status.
// E.g An extension that added statuses is now inactive or removed.
$settings['unregisteredOrderStatuses'] = self::get_unregistered_order_statuses();
// The separator used for attributes found in Variation titles.
$settings['variationTitleAttributesSeparator'] = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() );
if ( ! empty( $preload_data_endpoints ) ) {
$settings['dataEndpoints'] = isset( $settings['dataEndpoints'] )

View File

@ -29,15 +29,16 @@ class ReportCSVEmail extends \WC_Email {
$this->template_html = 'html-admin-report-export-download.php';
$this->template_plain = 'plain-admin-report-export-download.php';
$this->report_labels = array(
'revenue' => __( 'Revenue', 'woocommerce-admin' ),
'orders' => __( 'Orders', 'woocommerce-admin' ),
'products' => __( 'Products', 'woocommerce-admin' ),
'categories' => __( 'Categories', 'woocommerce-admin' ),
'coupons' => __( 'Coupons', 'woocommerce-admin' ),
'taxes' => __( 'Taxes', 'woocommerce-admin' ),
'downloads' => __( 'Downloads', 'woocommerce-admin' ),
'stock' => __( 'Stock', 'woocommerce-admin' ),
'customers' => __( 'Customers', 'woocommerce-admin' ),
'downloads' => __( 'Downloads', 'woocommerce-admin' ),
'orders' => __( 'Orders', 'woocommerce-admin' ),
'products' => __( 'Products', 'woocommerce-admin' ),
'revenue' => __( 'Revenue', 'woocommerce-admin' ),
'stock' => __( 'Stock', 'woocommerce-admin' ),
'taxes' => __( 'Taxes', 'woocommerce-admin' ),
'variations' => __( 'Variations', 'woocommerce-admin' ),
);
// Call parent constructor.

View File

@ -117,7 +117,6 @@ class WC_Tests_API_Reports_Variations extends WC_REST_Unit_Test_Case {
$request->set_query_params(
array(
'product_includes' => $variation->get_parent_id(),
'products' => $variation->get_parent_id(),
'variations' => $variation->get_id() . ',' . $variation_2->get_id(),
)
);